Перейти к основному содержимому

3. Предметно-ориентированное проектирование

Теоретические основы

Предметно-ориентированное проектирование (Domain-Driven Design, DDD) — это методология разработки программного обеспечения, впервые подробно изложенная Эриком Эвансом в его книге "Domain-Driven Design: Tackling Complexity in the Heart of Software". Основной принцип DDD заключается в том, чтобы сосредоточить внимание на понимании и моделировании бизнес-области (домена), для которой создается программное обеспечение. Это позволяет лучше отразить реальные процессы и правила бизнеса в коде.

Преимущества DDD

  • Улучшенная коммуникация: Формирование единого языка (ubiquitous language) между техническими специалистами и экспертами предметной области.
  • Гибкость и адаптивность: Использование таких понятий, как агрегаты, сущности и объекты-значения, позволяет легко модифицировать систему.
  • Фокус на бизнес-цели: Разработка приложений, которые напрямую решают задачи бизнеса.
  • Масштабируемость и модульность: Сложные системы разбиваются на управляемые части с помощью ограниченных контекстов (bounded contexts).

Ключевые концепции DDD

  • Единый язык (Ubiquitous Language)
    Единый язык — это общий словарь, используемый всеми участниками проекта: разработчиками, аналитиками, тестировщиками и экспертами предметной области.
    Цель: устранение недопонимания между технической командой и бизнесом.
    Пример: термин "Заказ" должен означать одно и то же как для разработчика, так и для менеджера по продажам.

  • Агрегаты (Aggregates)
    Группа связанных сущностей и объектов-значений, рассматриваемых как единая единица для целей транзакций и согласованности.
    Характеристики: Имеют корневую сущность (Aggregate Root), через которую осуществляется доступ ко всем элементам агрегата. Гарантируют целостность данных внутри своих границ.
    Пример: Заказ (агрегатный корень), содержащий позиции заказа.

  • Сущности (Entities)
    Объекты, имеющие уникальную идентичность, которая сохраняется независимо от изменений их атрибутов.
    Характеристики: Имеют идентификатор. Могут изменяться со временем, но остаются теми же объектами.
    Пример: Пользователь, Заказ, Счет.

  • Ограниченный контекст (Bounded Context)
    Это четко определённая граница, внутри которой доменная модель и термины имеют единое значение.
    Цель: избежать путаницы между различными частями системы.
    Пример: в одном ограниченном контексте "Пользователь" может означать клиента сервиса, а в другом — администратора.

  • Субдомен (Subdomain)
    Подразделение основного домена на более мелкие, специализированные области.
    Цель: упрощение управления сложной системой через декомпозицию.
    Пример: в интернет-магазине субдомены могут включать "Управление заказами", "Инвентарь", "Платежи".

  • Доменные события (Domain Events)
    События, происходящие в домене и представляющие значимые изменения состояния.
    Цель: информировать другие части системы о произошедших изменениях.
    Пример: событие "Заказ создан", "Статус заказа изменён".

Примеры моделей предметных областей

  • Система управления складом: доменная модель может включать такие сущности, как товары, заказы, клиенты, поставщики и т.д. Каждая сущность будет иметь свои свойства и методы, отражающие их поведение в реальной жизни. Например, товар может иметь уникальный идентификатор, цену, количество на складе и т.п.

  • Интернет-магазин: здесь домен включает категории товаров, покупателей, заказов, способов оплаты и доставки. Каждый из этих элементов имеет свои особенности и требования к обработке данных. Например, покупатель может создать заказ, выбрать способ доставки и оплатить товар.

  • CRM-система: доменная модель CRM-системы может включать клиентов, сделки, задачи, контактные данные и т.д. Каждая сущность связана с определенным контекстом и действиями, которые могут быть выполнены над ней. Например, задача может быть назначена на конкретного сотрудника, выполнена или перенесена на другой срок.

Больше примеров моделей DataSpace CE из разных доменных областей можно найти здесь: Примеры моделей

Книги по DDD

Основополагающие принципы методологии представлены в нескольких книгах. Первые две ниже – так называемые «синяя» и «красная» книги, достаточно объемные и фундаментальные труды. Для быстрого погружения рекомендуем последнюю книгу от Влада Ханонова: Изучаем DDD - предметно-ориентированное проектирование.

Реализация DDD в DataSpace CE на примере модели медицинской клиники

Общее описание

Модель представляет собой комплексную систему управления клиникой, включающую:

  • Управление организационной структурой
  • Ведение расписания врачей
  • Запись пациентов на приемы
  • Управление контактной информацией
  • Справочную систему для типизации данных

Система поддерживает иерархическое наследование и обеспечивает целостность данных через обязательные связи и проверки целостности, описывает организацию медицинского учреждения с точки зрения управления пациентами, врачами, расписаниями приёмов и пространственной организацией клиники.

Основные сущности

🏥 Организационные сущности

  • Clinic - клиника (основная организационная единица)

    • Содержит адрес и название
    • Стратегия наследования: JOINED
  • ClinicOffice - кабинеты в клинике

    • Номер кабинета (обязательное поле)
    • Тип кабинета (обязательное поле)
    • Связь с клиникой
  • ClinicSchedule - общее расписание клиники

    • Содержит список расписаний врачей
    • Связь с клиникой

👥 Персональные сущности

  • Person - базовая сущность человека

    • Имя (обязательное поле)
    • Фамилия (обязательное поле, историческое поле)
    • Дата рождения
    • Пол (обязательное поле)
  • Doctor - врач

    • Наследует от Person
    • Тип врача (обязательное поле)
    • Связь с Person
  • Customer - пациент/клиент

    • Наследует от Person
    • Номер страховки (обязательное поле)
    • Список контактной информации

🔗 Связующие сущности

  • ClinicDoctor - связь врача с клиникой

    • Обязательная связь с клиникой
    • Обязательная связь с врачом
  • ClinicCustomer - связь пациента с клиникой

    • Обязательная связь с клиникой
    • Обязательная связь с пациентом
  • CustomerContact - контактная информация пациента

    • Контактная информация
    • Родительская связь с пациентом

📅 Временные сущности

  • Period - абстрактный класс периода времени

    • Дата начала (обязательное поле)
    • Дата окончания (обязательное поле)
  • DoctorSchedule - расписание врача (наследует Period)

    • Связь с общим расписанием клиники
    • Список записей на прием
    • Связь с врачом клиники
    • Связь с кабинетом
  • DoctorAppointment - запись на прием (наследует Period)

    • Описание приема
    • Родительская связь с расписанием врача
    • Связь с пациентом клиники

📋 Справочники

  • DoctorType - типы врачей (словарь)

    • Название (обязательное поле)
    • Описание
  • OfficeType - типы кабинетов (словарь)

    • Название (обязательное поле)

🏠 Вспомогательные сущности

  • Address - адрес (встраиваемый класс)

    • Город
    • Улица
    • Номер квартиры
  • Sex - перечисление пола

    • MALE (мужской)
    • FEMALE (женский)

Структура XML-моделей в DataSpace CE

DataSpace CE предоставляет простой и понятный способ описания моделей данных в формате XML, который полностью соответствует принципам предметно-ориентированного проектирования. Платформа позволяет создавать сложные доменные модели, включающие агрегаты, сущности, свойства и их взаимосвязи, используя декларативный XML-синтаксис. Благодаря поддержке различных типов связей, наследования, встраиваемых классов и справочников, DataSpace CE обеспечивает гибкость в моделировании предметных областей любой сложности.

model - корневой элемент модели

Корневой элемент, который содержит описание всей доменной модели. Определяет имя модели и её версию для управления изменениями и совместимостью.

Атрибуты model:

  • model-name - уникальное имя модели
  • version - версия модели для отслеживания изменений
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model model-name="clinic" version="0.0.1">
<!-- Описание сущностей, свойств и связей -->
</model>

enum - перечисления

Определяет список фиксированных значений для создания справочных типов данных. Используется для ограничения возможных значений поля заранее определённым набором констант.

<enum name="Sex">
<value name="FEMALE"/>
<value name="MALE"/>
</enum>

class - определение сущности

Основной элемент для описания доменных сущностей. Определяет структуру объектов предметной области с их свойствами, связями и поведением.

Атрибуты class:

  • name - имя класса
  • is-abstract - указывает, является ли класс абстрактным (true/false)
  • is-dictionary - определяет, является ли класс справочником (true/false)
  • embeddable - определяет, является ли класс встраиваемым (true/false)
  • strategy - стратегия наследования (JOINED и др.)
  • extends - указывает родительский класс для наследования

Примеры различных типов классов:

Обычная сущность:

<class name="Clinic" is-abstract="false" is-dictionary="false" embeddable="false" strategy="JOINED">
<id category="AUTO_ON_EMPTY"/>
<property name="address" type="Address"/>
<property name="name" type="STRING" length="254"/>
</class>

Встраиваемый класс:

<class name="Address" is-abstract="false" is-dictionary="false" embeddable="true">
<property name="city" type="STRING" length="254"/>
<property name="flatNo" type="STRING" length="254"/>
<property name="street" type="STRING" length="254"/>
</class>

Абстрактный класс:

<class name="Period" is-abstract="true" is-dictionary="false" embeddable="false">
<property name="beginDate" type="LOCALDATETIME" mandatory="true" length="3"/>
<property name="endDate" type="LOCALDATETIME" mandatory="true" length="3"/>
</class>

Класс-справочник:

<class name="DoctorType" is-abstract="false" is-dictionary="true" embeddable="false">
<property name="descr" type="STRING" length="254"/>
<property name="name" type="STRING" mandatory="true" length="254"/>
</class>

Класс с наследованием:

<class name="DoctorSchedule" extends="Period" is-abstract="false" is-dictionary="false" embeddable="false" strategy="JOINED">
<id category="AUTO_ON_EMPTY"/>
<property name="clinicSchedule" type="ClinicSchedule" parent="true"/>
<property name="doctorAppointmentList" type="DoctorAppointment" collection="SET" mappedBy="doctorSchedule"/>
<reference name="clinicDoctor" type="ClinicDoctor" mandatory="true" integrity-check="true"/>
<reference name="clinicOffice" type="ClinicOffice" label="" mandatory="true" integrity-check="true"/>
</class>

id - идентификатор сущности

Определяет уникальный идентификатор для сущности. Каждая несамостоятельная сущность должна иметь способ уникальной идентификации.

Атрибуты id:

  • category - способ генерации идентификатора (например, AUTO_ON_EMPTY - автоматическая генерация если не указан вручную)
<id category="AUTO_ON_EMPTY"/>

property - свойство или атрибут класса

Определяет поля сущности, которые хранят данные. Может представлять простые типы данных, перечисления, встроенные классы или коллекции связанных объектов.

Атрибуты property:

  • name - имя свойства
  • type - тип данных (STRING, LOCALDATETIME, LOCALDATE или имя класса/перечисления)
  • mandatory - обязательность поля (true/false)
  • length - максимальная длина для строковых полей или точность для временных типов
  • collection - тип коллекции (SET, LIST) для связей "один ко многим"
  • mappedBy - указывает обратную сторону связи
  • parent - указывает родительскую связь в агрегате (true/false)

Примеры различных типов свойств:

Простое строковое свойство:

<property name="name" type="STRING" length="254"/>

Обязательное свойство:

<property name="insuranceNum" type="STRING" mandatory="true" length="254"/>

Свойство с типом-перечислением:

<property name="sex" type="Sex" mandatory="true"/>

Временное свойство:

<property name="beginDate" type="LOCALDATETIME" mandatory="true" length="3"/>

Свойство встроенного класса:

<property name="address" type="Address"/>

Свойство-коллекция с обратной связью:

<property name="customerContactList" type="CustomerContact" collection="SET" mappedBy="customer"/>

Родительское свойство в агрегате:

<property name="customer" type="Customer" parent="true"/>

reference - ссылка на другую сущность

Определяет связи между сущностями в модели. Представляет отношения "один к одному" или "один ко многим" и обеспечивает целостность данных.

Атрибуты reference:

  • name - имя ссылки
  • type - тип связанной сущности
  • mandatory - обязательность связи (true/false)
  • integrity-check - проверка целостности при удалении (true/false)
  • label - метка для отображения (может быть пустой)

Примеры различных типов ссылок:

Простая ссылка:

<reference name="doctor" type="Doctor" mandatory="true" integrity-check="true"/>

Обязательная ссылка:

<reference name="clinic" type="Clinic" mandatory="true"/>

Ссылка с проверкой целостности:

<reference name="person" type="Person" label="" mandatory="true" integrity-check="true"/>

Ссылка с меткой:

<reference name="clinicOffice" type="ClinicOffice" label="" mandatory="true" integrity-check="true"/>

Подробнее об агрегатах на примере модели клиники

В контексте DDD агрегат — это кластер объектов, которые рассматриваются как единое целое с точки зрения изменения данных. В каждом агрегате есть корневая сущность (aggregate root), через которую осуществляется доступ к остальным сущностям агрегата. Рассмотрим более подробно примеры агрегатов из модели медицинской клиники.

Агрегат Customer-CustomerContact

Что такое агрегат? Агрегат — это кластер тесно связанных объектов, которые рассматриваются как единое целое с точки зрения изменения данных. Агрегат имеет четкие границы и один корневой объект (aggregate root), через который внешние объекты получают доступ к внутренним элементам агрегата.

Пример агрегата Customer-CustomerContact:

  <class name="Customer" is-abstract="false" is-dictionary="false" embeddable="false" strategy="JOINED">
<id category="AUTO_ON_EMPTY"/>
<property name="customerContactList" type="CustomerContact" collection="SET" mappedBy="customer"/>
<property name="insuranceNum" type="STRING" mandatory="true" length="254"/>
<reference name="person" type="Person" label="" mandatory="true" integrity-check="true"/>
</class>

<class name="CustomerContact" is-abstract="false" is-dictionary="false" embeddable="false" strategy="JOINED">
<id category="AUTO_ON_EMPTY"/>
<property name="contactInfo" type="STRING" length="254"/>
<property name="customer" type="Customer" parent="true"/>
</class>

Структура агрегата:

  • Customer является корнем агрегата (aggregate root) — единственной точкой входа для внешних операций
  • CustomerContact является зависимой сущностью внутри агрегата, что обозначено атрибутом parent="true" у свойства customer
  • Связь mappedBy="customer" в коллекции customerContactList указывает на обратную сторону связи

Ключевые принципы данного агрегата:

  1. Инкапсуляция бизнес-правил: Все операции с контактами клиента должны проходить через корень агрегата Customer
  2. Транзакционная согласованность: Изменения в клиенте и его контактах происходят в рамках одной транзакции
  3. Жизненный цикл: Контакты не могут существовать без клиента — при удалении клиента удаляются и все его контакты
  4. Целостность данных: Агрегат гарантирует, что контактная информация всегда принадлежит конкретному клиенту

Практические преимущества:

  • Упрощение управления данными: все операции с контактами идут через один интерфейс
  • Гарантия целостности: невозможно создать "висячие" контакты без владельца
  • Оптимизация производительности: связанные данные загружаются и обновляются вместе

Агрегат ClinicSchedule-DoctorSchedule-DoctorAppointment

Многоуровневый агрегат расписаний:

Данный агрегат демонстрирует более сложную иерархическую структуру с тремя уровнями вложенности:

  <class name="ClinicSchedule" is-abstract="false" is-dictionary="false" embeddable="false" strategy="JOINED">
<id category="AUTO_ON_EMPTY"/>
<property name="doctorScheduleList" type="DoctorSchedule" collection="SET" mappedBy="clinicSchedule"/>
<reference name="clinic" type="Clinic" mandatory="true" integrity-check="true"/>
</class>

<class name="DoctorSchedule" extends="Period" is-abstract="false" is-dictionary="false" embeddable="false" strategy="JOINED">
<id category="AUTO_ON_EMPTY"/>
<property name="clinicSchedule" type="ClinicSchedule" parent="true"/>
<property name="doctorAppointmentList" type="DoctorAppointment" collection="SET" mappedBy="doctorSchedule"/>
<reference name="clinicDoctor" type="ClinicDoctor" mandatory="true" integrity-check="true"/>
<reference name="clinicOffice" type="ClinicOffice" label="" mandatory="true" integrity-check="true"/>
</class>

<class name="DoctorAppointment" extends="Period" is-abstract="false" is-dictionary="false" embeddable="false" strategy="JOINED">
<id category="AUTO_ON_EMPTY"/>
<property name="descr" type="STRING" length="254"/>
<property name="doctorSchedule" type="DoctorSchedule" parent="true"/>
<reference name="clinicCustomer" type="ClinicCustomer" label="" mandatory="true" integrity-check="true"/>
</class>

Иерархия агрегата:

  1. Уровень 1: ClinicSchedule — корень агрегата, общее расписание клиники
  2. Уровень 2: DoctorSchedule — расписание конкретного врача внутри общего расписания
  3. Уровень 3: DoctorAppointment — конкретная запись пациента на прием к врачу

Наследование от Period: Классы DoctorSchedule и DoctorAppointment наследуют от абстрактного класса Period, что обеспечивает:

  • Единообразную работу с временными интервалами
  • Автоматическое наследование полей beginDate и endDate
  • Возможность применения общих валидаций для временных периодов

Бизнес-логика агрегата:

  • Расписание врача не может существовать без общего расписания клиники
  • Запись на прием не может существовать без расписания врача
  • При изменении общего расписания могут потребоваться изменения в расписаниях врачей
  • Отмена расписания врача автоматически отменяет все записи на прием

Принцип Low Coupling (Слабое зацепление)

Что такое слабое зацепление? Слабое зацепление — это принцип проектирования, при котором различные компоненты системы минимально зависят друг от друга. Это означает, что изменения в одном компоненте не должны приводить к каскадным изменениям в других компонентах.

Реализация в модели клиники:

1. Четкое разделение агрегатов

Агрегаты в модели спроектированы как независимые единицы:

  <class name="DoctorAppointment" extends="Period" is-abstract="false" is-dictionary="false" embeddable="false" strategy="JOINED">
<id category="AUTO_ON_EMPTY"/>
<property name="descr" type="STRING" length="254"/>
<property name="doctorSchedule" type="DoctorSchedule" parent="true"/>
<reference name="clinicCustomer" type="ClinicCustomer" label="" mandatory="true" integrity-check="true"/>
</class>

Почему это работает:

  • Запись на прием (DoctorAppointment) ссылается на клиента через reference, а не включает его данные напрямую
  • Изменения в данных клиента не требуют изменений в записи на прием
  • Клиент может быть обновлен независимо от расписания

2. Использование ссылок вместо вложенных структур

  <class name="Doctor" is-abstract="false" is-dictionary="false" embeddable="false" strategy="JOINED">
<id category="AUTO_ON_EMPTY"/>
<property name="doctorType" type="DoctorType" mandatory="true"/>
<reference name="person" type="Person" label="" mandatory="true" integrity-check="true"/>
</class>

Преимущества данного подхода:

  • Врач и персона могут изменяться независимо
  • Одна персона может быть связана с несколькими ролями (врач, пациент и т.д.)
  • Легче тестировать каждый компонент отдельно
  • Упрощается рефакторинг и добавление новых функций

3. Разделение по доменным областям

  <class name="ClinicOffice" is-abstract="false" is-dictionary="false" embeddable="false" strategy="JOINED">
<id category="AUTO_ON_EMPTY"/>
<property name="officeNo" type="STRING" mandatory="true" length="254"/>
<property name="officeType" type="OfficeType" mandatory="true"/>
<reference name="clinic" type="Clinic" mandatory="true" integrity-check="true"/>
</class>

Кабинеты клиники выделены в отдельную сущность, что позволяет:

  • Управлять кабинетами независимо от расписаний
  • Легко добавлять новые типы кабинетов
  • Переназначать кабинеты между врачами без изменения их расписаний

Принцип High Cohesion (Высокая сплоченность)

Что такое высокая сплоченность? Высокая сплоченность означает, что элементы внутри одного модуля или компонента тесно связаны по функциональности и работают для достижения одной цели. Тесно связанные данные и операции должны находиться вместе.

Реализация в модели клиники:

1. Объединение связанных данных в одном агрегате

  <class name="Customer" is-abstract="false" is-dictionary="false" embeddable="false" strategy="JOINED">
<id category="AUTO_ON_EMPTY"/>
<property name="customerContactList" type="CustomerContact" collection="SET" mappedBy="customer"/>
<property name="insuranceNum" type="STRING" mandatory="true" length="254"/>
<reference name="person" type="Person" label="" mandatory="true" integrity-check="true"/>
</class>

Почему данные объединены:

  • Контакты клиента всегда изменяются в контексте конкретного клиента
  • Номер страховки напрямую связан с клиентом как пациентом
  • Все эти данные нужны для выполнения бизнес-операций с клиентом

Практические преимущества:

  • Все операции с клиентом выполняются в одном месте
  • Легче поддерживать бизнес-правила (например, уникальность номера страховки)
  • Улучшенная производительность при загрузке связанных данных

2. Использование встраиваемых классов для группировки связанных свойств

  <class name="Address" is-abstract="false" is-dictionary="false" embeddable="true">
<property name="city" type="STRING" length="254"/>
<property name="flatNo" type="STRING" length="254"/>
<property name="street" type="STRING" length="254"/>
</class>

<class name="Clinic" is-abstract="false" is-dictionary="false" embeddable="false" strategy="JOINED">
<id category="AUTO_ON_EMPTY"/>
<property name="address" type="Address"/>
<property name="name" type="STRING" length="254"/>
</class>

Преимущества встроенного класса Address:

  • Логически связанные поля адреса объединены в одну структуру
  • Можно легко добавить валидацию адреса как единого целого
  • Улучшена читаемость модели — понятно, что эти поля относятся к адресу
  • Возможность повторного использования в других классах

3. Функциональная сплоченность в агрегатах

  <class name="DoctorSchedule" extends="Period" is-abstract="false" is-dictionary="false" embeddable="false" strategy="JOINED">
<id category="AUTO_ON_EMPTY"/>
<property name="clinicSchedule" type="ClinicSchedule" parent="true"/>
<property name="doctorAppointmentList" type="DoctorAppointment" collection="SET" mappedBy="doctorSchedule"/>
<reference name="clinicDoctor" type="ClinicDoctor" mandatory="true" integrity-check="true"/>
<reference name="clinicOffice" type="ClinicOffice" label="" mandatory="true" integrity-check="true"/>
</class>

Функциональная сплоченность проявляется в том, что:

  • Все данные необходимы для полного описания расписания врача
  • Врач, кабинет, временной период и записи работают как единое целое
  • Изменение любого элемента может повлиять на весь контекст расписания

Ссылки

Руководство по ведению модели данных DataSpace CE

"Domain-Driven Design Quickly(ENG)"

DDD Community

Martin Fowler's Bliki

"Domain-Driven Design: стратегическое проектирование. Часть 1"

"Domain-Driven Design: тактическое проектирование. Часть 2"

"Domain-driven design: рецепт для прагматика"

"DDD на практике. Проектирование списка желаний"